<?php
/**
 * 
 * Domain model for managing info about installed apps
 *
 * @category Abovo
 * 
 * @package Abovo
 * 
 * @author Antti Holvikari <anttih@gmail.com>
 * 
 * @license http://opensource.org/licenses/gpl-license.php GPL
 * 
 * @version $Id: Menu.php 509 2007-07-01 10:00:53Z anttih $
 * 
 */

/**
 * 
 * Domain model for managing info about installed apps
 * 
 * The Abovo application platform is divided into modules and apps.
 * A module consists of one or more apps. An app is a Solar-style 
 * app with actions such as edit, delete and so on. This class'
 * purpose is to store the info about all the modules, apps and
 * actions so that you can show them as a menu to end-users. The
 * actual info about all of those is stored in the locale files of
 * each class for easy maintenance. This also means that all the
 * info is locale-aware.
 * 
 * This class is used as a singleton for the best performace. A cache
 * is used for caching the whole menu because fetching all the info
 * is not so cheap file-i/o -wise. Also, the whole menu is stored in
 * ``$this->menu`` for one instance.
 * 
 * The most important part about all this is that a list of all the 
 * apps that are installed or available is stored somewhere that
 * stays intact across requests, and even after the cache is destroyed.
 * This is what ``Abovo_Model_Apps`` is used for. As long as you can
 * get a list of the apps from it, a call to 
 * Abovo_Model_Menu::fetchAll() will fetch all the info for them and
 * maintain a cache for the menu structure.
 * 
 * A few examples:
 * 
 * {{{
 * Solar::register('menu', Solar::factory('Abovo_Menu'));
 * $menu = Solar::registry('menu');
 * 
 * // insert a new app and fetch all of it's info
 * // for current locale
 * $menu->insert('Vendor_App_Module_ACoolNewApp');
 * 
 * // get all available modules and apps
 * $data = $menu->menu;
 * 
 * // change language
 * Solar::$locale->setCode('fi_FI');
 * 
 * // this will get menu for fi_FI
 * // it will also update the cache
 * $menu->load();
 * $data = $menu->menu;
 * 
 * // fetch list for a user handle.
 * // this will only fetch apps that user has access to
 * $user_menu = $menu->getAllByHandle();
 * }}}
 * 
 * @category Abovo
 * 
 * @package Abovo
 * 
 */
class Abovo_Menu extends Solar_Base {
    
    /**
     * 
     * The whole menu structure
     * 
     * Holds the whole menu structure for current locale.
     * Array is in the form of:
     * 
     * {{{
     * $menu = array(
     *     'Vendor_App_Module' => array(
     *         'name'  => 'Name',
     *         'descr' => 'Description of the module',
     *         'apps'  => array(
     *             'Vendor_App_Module_Example' => array(
     *                 'name'    => 'Example',
     *                 'descr'   => 'Description of example app',
     *                 'actions' => array(
     *                     'add'    => array('name' => 'Add'),
     *                     'edit'   => array('name' => 'Edit'),
     *                 ),
     *             ),
     *         ),
     *     ),
     * );
     * }}}
     * 
     * @var array
     * 
     */
    public $menu = array();
    
    /**
     * 
     * Permissions model object
     * 
     * @var Abovo_Model_Perms
     * 
     */
    public $perms;
    
    /**
     * 
     * Installed apps (their class name)
     *
     * @var array
     * 
     */
    protected $_apps_known = array();
    
    /**
     * 
     * Apps model object
     * 
     * @var Abovo_Model_Apps
     * 
     */
    private $_apps;
    
    /**
     * 
     * Cache object instance for menu caching
     *
     * @var Solar_Cache_Adapter_* object
     * 
     */
    private $_cache;
    
    /**
     * 
     * Locale for which the menu has been fetched
     * 
     * @var string
     * 
     */
    private $_locale = null;
    
    /**
     * 
     * Constructor
     * 
     * Sets things up and fecthes the list of apps, loads
     * the whole menu for the first time.
     * 
     * @return void
     * 
     */
    public function __construct($config = array())
    {
        parent::__construct($config);
        
        $this->_apps  = Solar::factory('Abovo_Model_Apps');
        
        // create cache instance
        $this->_cache = Solar::factory('Solar_Cache');
        
        // set permissions model
        $this->perms = Solar::factory('Abovo_Model_Perms');
        
        // set current locale
        $this->_locale = Solar::$locale->getCode();
        
        // fetch all apps from database
        $this->_apps_known = $this->_apps->fetchAllApps();
        
        // load the menu structure
        $this->_load();
    }
    
    
    /**
     * 
     * Forcibly refreshes all app info for current locale
     * 
     * @return void
     * 
     */
    public function refresh()
    {
        if (empty($this->_apps_known)) {
            // no apps; nothing to do
            // this will avoid caching of empty menu
            return;
        }
        
        // get Solar's current locale
        $locale = Solar::$locale->getCode();
        
        // get all app info
        $this->menu = $this->_fetchAllAppInfo($locale);
        
        // save to cache
        $this->_cacheSave($locale, $this->menu);
        
        // set current locale
        $this->_locale = $locale;
    }
    
    /**
     * 
     * Fetches menu for user handle
     * 
     * Uses Solar_Access and Solar_Role to determine
     * which apps/actions are allowed for the user.
     * Does *not* fetch anything, use load() or refresh()
     * to fetch.
     * 
     * @return array Menu as an array
     * 
     */
    public function getAllByHandle($handle)
    {
        // load roles for user handle
        $role = Solar::factory('Solar_Role');
        $role->load($handle);
        
        // load access data for user handle and roles
        $access = Solar::factory('Solar_Access');
        $access->load($handle, $role->list);
        
        // @todo Has to be a better way to do this :-(
        $menu = array();
        foreach ($this->menu as $module => $module_info) {
            foreach ($module_info['apps'] as $app => $app_info) {
                foreach ($app_info['actions'] as $action => $action_info) {
                    if ($access->isAllowed($app, $action)) {
                        if (! array_key_exists($module, $menu)) {
                            $menu[$module] = array(
                                'name' => $module_info['name'],
                                'descr' => $module_info['descr'],
                                'apps' => array(),
                            );
                        }
                        
                        // does this app already exist?
                        if (! array_key_exists($app, $menu[$module]['apps'])) {
                            $menu[$module]['apps'][$app] = array(
                                'name'    => $app_info['name'],
                                'descr'    => $app_info['descr'],
                                'actions' => array(),
                            );
                        }
                        
                        // module and app should be set now, we can add
                        // the action
                        $menu[$module]['apps'][$app]['actions'][$action] =
                            array('name' => $action_info['name']);
                    }
                }
            }
        }
        
        // done!
        return $menu;
    }
    
    /**
     * 
     * Adds one app to menu
     * 
     * First, fetches app info, then adds app to database.
     * Finally, saves menu to cache for further use. 
     * 
     * @param string $app App's class name
     * 
     * @return bool False on failure, true on success
     * 
     */
    public function insert($app)
    {
        if ($this->appExists($app)) {
            return false;
        }
        
        // fetch it's info
        $info = $this->_fetchAppInfo($this->_locale, $app);
        
        // add it to database
        $this->_apps->insert(array('name' => $app));
        
        // add to menu
        $this->menu = $this->_merge($this->menu, $info);
        
        // update cache
        $this->_cacheSave($this->_locale, $this->menu);
        
        // add it to apps list
        $this->_apps_known[] = $app;
        
        return true;
    }
    
    /**
     * 
     * Returns all available apps
     * 
     * @return array
     * 
     */
    public function getApps()
    {
        return $this->_apps_apps;
    }
    
    /**
     * 
     * Checks if an app has already been added
     * 
     * @param string $app Class name for app
     * 
     * @return bool
     * 
     */
    public function appExists($app)
    {
        return in_array($app, $this->_apps_known);
    }
    
    /**
     * 
     * Forcibly clears cache entry for current locale
     *
     * @return void
     * 
     */
    public function cacheClear()
    {
        $locale = Solar::$locale->getCode();
        $this->_cache->delete($this->_cacheKey($locale));
    }
    
    /**
     * 
     * Fetch all available app info
     * 
     * First tries cache if $this->menu is empty or locale
     * has changed. Once the menu is fetched from cache or
     * locale files we check if it contains all the apps
     * in ``$this->_apps_known``. If some apps are missing,
     * info for them is fetched and cache is saved.
     * 
     * @return void Use $this->list to get the loaded menu
     * 
     */
    private function _load()
    {
        // get current locale
        $locale = Solar::$locale->getCode();
        
        // empty menu or wrong locale?
        if (empty($this->menu) || $this->_locale != $locale) {
            // ...yes, fetch all apps' info from locale files
            $menu = $this->_cacheFetch($locale);
            if (! $menu) {
                // cache miss; force a refresh and return
                $this->refresh();
                return;
            }
            // we can safely set the menu
            $this->menu = $menu;
            unset($menu);
        }
        
        // $this->menu may have correct app info or not
        // @todo Remove apps not in known apps
        // @todo Add delete()
        
        // set current locale
        $this->_locale = $locale;
    }
    
    /**
     * 
     * Fetches all app info from locale files
     * 
     * @param string $locale Locale code
     * 
     * @param array $list List of apps to fetch info for
     * (optional)
     * 
     * @return array Menu as an array
     * 
     */
    private function _fetchAllAppInfo($locale, $list = null)
    {
        // by default, use this
        $apps = $this->_apps_known;
        if (! empty($list)) {
            $apps = (array) $list;
        }
        
        $menu = array();
        foreach ($apps as $app) {
            $info = $this->_fetchAppInfo($locale, $app);
            $menu = $this->_merge($menu, $info);
        }
        
        return $menu;
    }
    
    /**
     * 
     * Fetches app info
     * 
     * Fetches module, app and all the actions' info
     * for one app. Info is stored in the Solar locale
     * files.
     *
     * @param string $locale Locale code
     * 
     * @param string $app App's class name
     * 
     * @return bool|array All menu info for one app, including
     * module, app and actions' info. False if app was not found.
     * 
     */
    private function _fetchAppInfo($locale, $app)
    {
        try {
            // try loading this app
            Solar::loadClass($app);
        } catch(Solar_Exception $e) {
            // there was an error, but it's not fatal
            // just inform we couldn't add this app
            return array();
        }
        
        $info = array();
        
        // -------------------------------
        // 
        // Module info
        // 
        
        // get module class name, it's the first parent
        $parents = Solar::parents($app);
        $module = $parents[0];
        
        // set module info
        $info[$module] = array(
            'name'  => Solar::$locale->fetch($module, 'MODULE_NAME'),
            'descr' => Solar::$locale->fetch($module, 'MODULE_DESCR'),
            'apps'  => array(),
        );
        
        // --------------------------------
        // 
        // App info
        // 
        
        // set app info
        $info[$module]['apps'][$app] = array(
            'name'   => Solar::$locale->fetch($app, 'APP_NAME'),
            'descr'  => Solar::$locale->fetch($app, 'APP_DESCR'),
            'actions' => array(),
        );
        
        // -------------------------------
        // 
        // Action info
        //
        
        // get action info with reflection
        $class = new ReflectionClass($app);
        $methods = $class->getMethods();
        
        // go through all methods and find actions
        foreach ($methods as $method) {
            // get methods name
            $name = $method->getName();
            
            // is it a valid action?
            // needs to be public and match regex
            if ($name == 'actionError'
            || ! $method->isPublic()
            || ! preg_match("/action([A-Z])([a-z]*)/", $name)) {
                // not valid, move to next
                continue;
            }
            
            // take 'action' off from the beginning of the method name
            $action = strtolower(ltrim($name, 'action'));
            $locale_key = 'APP_ACTION_' . strtoupper($action);
            
            // add action
            $info[$module]['apps'][$app]['actions'][$action] = array(
                'name' => Solar::$locale->fetch($app, $locale_key),
            );
        }
        
        // add done!
        return $info;
    }
    
    /**
     * 
     * Fetches menu from cache
     * 
     * @param string $locale Locale code
     * 
     * @return bool|array False on failure, menu as an array on success
     * 
     */
    private function _cacheFetch($locale)
    {
        $key = $this->_cacheKey($locale);
        return $this->_cache->fetch($key);
    }
    
    /**
     * 
     * Saves menu to cache
     * 
     * @return bool
     * 
     */
    private function _cacheSave($locale, $menu)
    {
        $key = $this->_cacheKey($locale);
        return $this->_cache->save($key, $menu);
    }
    
    /**
     * 
     * Creates a cache entry key
     * 
     * No need to hash('md5', ), Solar_Cache does it for us.
     * 
     * @return string Cache entry name
     * 
     */
    private function _cacheKey($locale)
    {
        return $locale . get_class($this);
    }
    
    
    /**
     * 
     * Merges to sets of menu arrays
     * 
     * array_merge() doesn't handle recursion correctly and
     * array_merge_recursive() doesn't overwrite existing keys;
     * thus this method.
     * 
     * @return array Menu as an array
     * 
     */
    private function _merge(array $menu, array $merge)
    {
        foreach ((array) $merge as $module => $data) {
            // does $menu have this module already?
            if (! array_key_exists($module, $menu)) {
                // ...no, just add this to it.
                $menu[$module] = $data;
                continue;
            }
            
            // module exists; just overwrite apps
            $menu[$module]['apps'] = array_merge(
                $menu[$module]['apps'],
                $data['apps']
            );
        }
        
        return $menu;
    }
}
